Skip to content

feat(node): export toWebRequest(), the IncomingMessage→Request conversion inside toNodeHandler#2390

Merged
felixweinberger merged 8 commits into
mainfrom
fweinberger/to-web-request
Jun 30, 2026
Merged

feat(node): export toWebRequest(), the IncomingMessage→Request conversion inside toNodeHandler#2390
felixweinberger merged 8 commits into
mainfrom
fweinberger/to-web-request

Conversation

@felixweinberger

@felixweinberger felixweinberger commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Routing on isLegacyRequest from a plain Node (req, res) handler currently means hand-building a
globalThis.Request out of req.headers:

const probe = new globalThis.Request(`http://localhost${req.url}`, {
    method: req.method,
    headers: req.headers as Record<string, string>
});
await ((await isLegacyRequest(probe, req.body)) ? legacy(req, res) : modern(req, res, req.body));

That's a smell. toNodeHandler already contains the correct conversion (header arrays, HTTP/2
pseudo-headers, the parsed-body re-serialization) as a private helper — users just can't reach it. It also
nudges people toward the two-argument isLegacyRequest(probe, body), which reads as if the second argument
were required. It isn't: the predicate clones internally, so isLegacyRequest(request) is the whole API.
Our own examples taught the same two-argument pattern, each behind ~16 lines of buffer-read + JSON.parse +
hand-built Request.

This exports the conversion as toWebRequest() and moves both examples onto it. The Express one
(legacy-routing) becomes:

import { toWebRequest } from '@modelcontextprotocol/node';

// Express — the body parser already consumed the stream, so pass it along.
const probe = await toWebRequest(req, req.body);
await ((await isLegacyRequest(probe)) ? handleLegacy(req, res) : modernNode(req, res, req.body));

and the plain node:http one (elicitation):

// toWebRequest reads the stream once, so the body now lives in `request`.
// The predicate runs first: it classifies an internal clone, leaving
// `request` readable for the .json() both arms need.
const request = await toWebRequest(req);
const legacy = await isLegacyRequest(request);
const body: unknown = req.method === 'POST' ? await request.json().catch(() => {}) : undefined;
await (legacy ? handleLegacy(req, res, body) : modern(req, res, body));

How:

  • toWebRequest(req, parsedBody?, options?) is the existing private conversion, exported. The function body
    is unchanged, and toNodeHandler now calls it with the abort signal in the options bag instead of a
    positional argument, so the adapter's behavior is identical.
  • It returns Promise<Request> (it reads the Node stream). Pass parsedBody when a body parser already
    consumed the stream — it's re-serialized as the Request body and the entity headers are rewritten. Pass
    options.signal to tie the constructed request to client disconnect, the way toNodeHandler does.
  • isLegacyRequest's docs now lead with the single-argument form and present parsedBody as the perf
    escape hatch it is, not a required companion.

Motivation and Context

isLegacyRequest takes a web-standard Request, but the Node hosting path only has an IncomingMessage.
The conversion between the two already existed inside toNodeHandler; exporting it removes the last reason
to build a globalThis.Request by hand — including in our own examples, which are what people copy.

How Has This Been Tested?

New unit tests in packages/middleware/node/test/toWebRequest.test.ts cover the stream-read and
parsedBody body paths (the latter asserts the Node stream is never touched), the Host-derived URL, header
copying (multi-valued append, HTTP/2 pseudo-header skipping), the signal option, and the clone-readability
contract isLegacyRequest relies on. The existing toNodeHandler suite passes unchanged, which is the
no-behavior-change check for the adapter path. The full example e2e suite passes (both rewritten stories
across all their transport × era legs), along with typecheck:all, lint, and the docs build.

Breaking Changes

None. New export; toNodeHandler behavior is unchanged; the isLegacyRequest change is documentation only;
the examples route every input express.json() parses identically.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

One edge worth naming: for a POST express.json() doesn't parse (a non-JSON content type — not valid MCP),
body-parser 2.x leaves req.body undefined and the Node stream unread, so toWebRequest reads it and the
predicate classifies the real bytes (the old hand-built, header-only probe never saw that body at all). Not
meaningful for spec traffic, and the old code already routed it differently between Express 4 and 5 (an
unparsed body is {} on 4 but undefined on 5, and the two classify differently).

One deliberate nuance: the example's predicate now reads the body through the same TextDecoder path the
entry itself uses, where the old hand-rolled read used Buffer#toString('utf8'). The only observable
difference is a leading UTF-8 BOM, which the old read left in (so JSON.parse failed and the request fell
to the legacy arm) and is now stripped, so the body classifies on its content.

The only other Request construction in examples/ is authServer.ts's
new Request(new URL(req.originalUrl, issuer)), which is left alone on purpose: it synthesizes an OAuth
discovery URL from issuer, not a faithful conversion of the inbound request, so toWebRequest would
change it.

@modelcontextprotocol/node README and changeset included. isLegacyRequest's rewritten docs are precise
about the one case parsedBody is needed rather than just faster: a Request whose own body was already
read (the internal clone would throw). Behind a body parser the right fix is toWebRequest(req, req.body)
— the predicate still takes one argument.

…sion inside toNodeHandler

Routing on isLegacyRequest from a plain Node `(req, res)` handler required
hand-assembling a `globalThis.Request` from `req.headers` (header-array and
HTTP/2 pseudo-header pitfalls included), even though toNodeHandler already
contains the correct conversion as a private helper.

Export that helper as `toWebRequest(req, parsedBody?, options?)` from
`@modelcontextprotocol/node`. The function body is unchanged; toNodeHandler
now calls it with `{ signal }` in an options bag instead of a positional
AbortSignal, so the adapter's behavior is unchanged. Pass `parsedBody` when a
body parser already consumed the Node stream; `options.signal` ties the
constructed request to client disconnect the way toNodeHandler does.

Adds unit tests for both body paths (the parsedBody case asserts the Node
stream is never touched), header copying, the signal option, and the
clone-readability contract isLegacyRequest relies on. README + changeset.
isLegacyRequest(request) is the whole API: the body is read from an internal
clone, so the caller's request stays readable for whichever handler it is
routed to. That fact was the final paragraph of a long doc comment, after the
routing semantics, which made the optional `parsedBody` read like a required
companion.

Move it into a lead paragraph: the single-argument form first, `parsedBody`
as a perf escape hatch for a body the caller already holds parsed, and the
one case it is genuinely needed (a Request whose own body was already read,
where the internal clone would throw). Also point Node `(req, res)` callers
at `toWebRequest(req)` — and `toWebRequest(req, req.body)` behind a body
parser, since the Node stream is already drained there — from the companion
`@modelcontextprotocol/node` change.

Documentation only; no behavior change.
…hand-built globalThis.Request

The legacy-routing and elicitation servers each hand-read the Node body into
Buffer chunks, JSON.parsed it, and constructed a `globalThis.Request` from
`req.headers` (cast to `Record<string, string>`) just to call
isLegacyRequest — with a second argument the predicate does not need.

Use the exported `toWebRequest(req[, parsedBody])` from
`@modelcontextprotocol/node` instead, and call the predicate with just the
request. In legacy-routing (Express 5) the conversion is always handed a
parsed body (`req.body ?? {}` — body-parser leaves `req.body` undefined and
the stream unread for a non-JSON content type), so it never consumes a
stream the legacy transport still needs to read. In elicitation (plain
`node:http`) `toWebRequest` reads the stream once and both routing arms take
the body from the resulting `Request`; the predicate runs first, since it
classifies an internal clone and leaves the request readable.

No change to what either example demonstrates: for every input class the
same arm fires with the same body value as before.
@felixweinberger felixweinberger requested a review from a team as a code owner June 29, 2026 18:28
@changeset-bot

changeset-bot Bot commented Jun 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1427667

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@modelcontextprotocol/server Patch
@modelcontextprotocol/node Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2390

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2390

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2390

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2390

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2390

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2390

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2390

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2390

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2390

commit: 1427667

Comment thread examples/legacy-routing/server.ts Outdated
… unparsed body

A `{}` probe classifies as an invalid JSON-RPC body (a reject), not as
legacy, so a POST that `express.json()` does not parse routes to the strict
modern arm — the previous comment claimed the legacy routing was preserved.

The code is unchanged: `?? {}` is still the right call, because the
alternative (no `parsedBody`) makes `toWebRequest` read the Node stream out
from under the legacy transport, which still needs it. Say what the line
does instead of what it preserves.
…ts docs

Drop the `?? {}`. It existed to keep `toWebRequest` from reading the Node
stream for the one input where Express leaves `req.body` undefined AND the
stream unread — a POST whose content type `express.json()` will not parse.
That is not valid MCP traffic, the example's client never sends it, and the
old code already routed it differently across Express majors (an unparsed
body is `{}` on Express 4 but `undefined` on Express 5), so there was no
stable behavior to preserve and no justification for a special case in the
canonical example.

`toWebRequest(req, req.body)` is the form the function's own docs and the
changeset teach; the example now matches them, and for everything
`express.json()` parses it routes exactly as before.
Keep what it is, where it came from, a two-line usage, and the one sharp
edge (no `parsedBody` -> the Node stream is read to completion, so read the
body from the returned `Request`, not from `req`; with `parsedBody` nothing
is read). Drop the URL/header/method mechanics inventory, the entity-header
rewrite detail (an implementation detail nobody programs against), the full
Express route duplicated from examples/legacy-routing, and the essay on the
`signal` option whose useful content is one line.
Comment thread packages/middleware/node/src/toNodeHandler.ts
…equests

An HTTP/2 request reaching a Node http2-compat handler carries its authority
only as the `:authority` pseudo-header, with no `host` entry, so the
conversion produced `http://localhost/<path>` with no Host header. Fall back
to `:authority` the way Node's own `request.authority` getter does; HTTP/1.1
requests are unchanged (`host` wins).

The HTTP/2 test previously supplied a `host` header alongside `:authority` —
a combination real HTTP/2 clients don't send — so it passed without covering
this. It now uses pseudo-headers only, plus a case pinning host-over-
authority precedence.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found in this revision — the earlier concerns (HTTP/2 :authority host derivation, the legacy-routing probe construction) are addressed in the current diff with test coverage. Deferring to a maintainer because exporting toWebRequest is a public API-surface decision (minor bump on @modelcontextprotocol/node) that warrants a human call under the repo's minimalism principle.

Extended reasoning...

Overview

This PR exports the previously-private nodeRequestToFetchRequest conversion as toWebRequest(req, parsedBody?, options?) from @modelcontextprotocol/node, switches toNodeHandler to call it (signal moved into an options bag — adapter behavior unchanged), rewrites the elicitation and legacy-routing examples to use it instead of hand-built globalThis.Request probes, rewrites the isLegacyRequest JSDoc to lead with the single-argument form, and adds a dedicated unit-test suite plus README/changeset entries.

Status of prior feedback

Both earlier inline comments are addressed in the current revision: the URL host now falls back to the :authority pseudo-header (with tests covering the no-host HTTP/2 case and the precedence case), and the legacy-routing example dropped the ?? {} probe in favor of plain toWebRequest(req, req.body), with the unparsed-body edge case acknowledged honestly in the PR description rather than claimed away. The bug-hunting system found no bugs in this revision, and I did not find new correctness issues: the toNodeHandler call site is behaviorally identical, the parsedBody re-serialization path and entity-header rewriting are unchanged code, and the clone-readability contract isLegacyRequest relies on is tested.

Security risks

Low. The conversion copies inbound headers and body into a web-standard Request; no auth, crypto, or permission logic is touched. The :authority fallback slightly changes which header feeds the constructed URL host for HTTP/2 requests, but host validation middleware still reads the Host header and fails closed, so this is a fidelity improvement rather than a validation-relevant change.

Level of scrutiny

Moderate. The runtime logic change is small (one host-derivation fallback, one signature refactor), but the PR adds a new export to a published package — a one-way door under this repo's "burden of proof is on addition" principle. The justification is reasonable (the conversion already exists internally, and the SDK's own examples were hand-rolling it), but whether toWebRequest belongs in the public surface vs. a cookbook is a maintainer decision, not one I should approve past.

Other factors

Test coverage for the new export is thorough (stream vs parsedBody body paths, multi-valued headers, pseudo-header skipping, signal option, clone readability), the changesets accurately describe the changes, and the example rewrites keep behavior for spec-valid traffic. Nothing here blocks the PR; it just needs a human call on the API addition.

@felixweinberger felixweinberger merged commit 6cc7b1c into main Jun 30, 2026
20 checks passed
@felixweinberger felixweinberger deleted the fweinberger/to-web-request branch June 30, 2026 10:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant